local table = table
local type = type
local tonumber = tonumber
local pairs = pairs
local error = error

local M = {}

local LIMIT = 8192

local function chunksize(readbytes, body)
    while true do
        local f, e = body:find("\r\n", 1, true)
        if f then
            return tonumber(body:sub(1, f - 1), 16), body:sub(e + 1)
        end
        if #body > 128 then
            -- pervent the attacker send very long stream without \r\n
            return
        end
        body = body .. readbytes()
    end
end

local function readcrln(readbytes, body)
    if #body >= 2 then
        if body:sub(1, 2) ~= "\r\n" then
            return
        end
        return body:sub(3)
    else
        body = body .. readbytes(2 - #body)
        if body ~= "\r\n" then
            return
        end
        return ""
    end
end

function M.recvheader(readbytes, lines, header)
    if #header >= 2 then
        if header:find "^\r\n" then
            return header:sub(3)
        end
    end
    local result
    local e = header:find("\r\n\r\n", 1, true)
    if e then
        result = header:sub(e + 4)
    else
        while true do
            local bytes = readbytes()
            header = header .. bytes
            e = header:find("\r\n\r\n", -(#bytes) - 3, true)
            if e then
                result = header:sub(e + 4)
                break
            end
            if header:find "^\r\n" then
                return header:sub(3)
            end
            if #header > LIMIT then
                return
            end
        end
    end
    for v in header:gmatch("(.-)\r\n") do
        if v == "" then
            break
        end
        table.insert(lines, v)
    end
    return result
end

function M.parseheader(lines, from, header)
    local name, value
    for i = from, #lines do
        local line = lines[i]
        if line:byte(1) == 9 then -- tab, append last line
            if name == nil then
                return
            end
            header[name] = header[name] .. line:sub(2)
        else
            name, value = line:match "^(.-):%s*(.*)"
            if name == nil or value == nil then
                return
            end
            name = name:lower()
            if header[name] then
                local v = header[name]
                if type(v) == "table" then
                    table.insert(v, value)
                else
                    header[name] = {v, value}
                end
            else
                header[name] = value
            end
        end
    end
    return header
end

function M.recvchunkedbody(readbytes, bodylimit, header, body)
    local result = ""
    local size = 0

    while true do
        local sz
        sz, body = chunksize(readbytes, body)
        if not sz then
            return
        end
        if sz == 0 then
            break
        end
        size = size + sz
        if bodylimit and size > bodylimit then
            return
        end
        if #body >= sz then
            result = result .. body:sub(1, sz)
            body = body:sub(sz + 1)
        else
            result = result .. body .. readbytes(sz - #body)
            body = ""
        end
        body = readcrln(readbytes, body)
        if not body then
            return
        end
    end

    local tmpline = {}
    body = M.recvheader(readbytes, tmpline, body)
    if not body then
        return
    end

    header = M.parseheader(tmpline, 1, header)

    return result, header
end

local function recvbody(interface, code, header, body)
    local length = header["content-length"]
    if length then
        length = tonumber(length)
    end
    if length then
        if #body >= length then
            body = body:sub(1, length)
        else
            local padding = interface.read(length - #body)
            body = body .. padding
        end
    elseif code == 204 or code == 304 or code < 200 then
        -- See https://stackoverflow.com/questions/15991173/is-the-content-length-header-required-for-a-http-1-0-response
        body = ""
    else
        -- no content-length, read all
        body = body .. interface.readall()
    end
    return body
end

function M.request(interface, method, host, url, recvheader, header, content)
    local read = interface.read
    local write = interface.write
    local header_content = ""
    if header then
        if not header.Host then
            header.Host = host
        end
        for k, v in pairs(header) do
            header_content = string.format("%s%s:%s\r\n", header_content, k, v)
        end
    else
        header_content = string.format("host:%s\r\n", host)
    end

    if content then
        local data = string.format("%s %s HTTP/1.1\r\n%sContent-length:%d\r\n\r\n", method, url, header_content,
            #content)
        write(data)
        write(content)
    else
        local request_header =
            string.format("%s %s HTTP/1.1\r\n%sContent-length:0\r\n\r\n", method, url, header_content)
        write(request_header)
    end

    local tmpline = {}
    local body = M.recvheader(read, tmpline, "")
    if not body then
        error("Recv header failed")
    end

    local statusline = tmpline[1]
    local code, info = statusline:match "HTTP/[%d%.]+%s+([%d]+)%s+(.*)$"
    code = assert(tonumber(code))

    local header = M.parseheader(tmpline, 2, recvheader or {})
    if not header then
        error("Invalid HTTP response header")
    end
    return code, body, header
end

function M.response(interface, code, body, header)
    local mode = header["transfer-encoding"]
    if mode then
        if mode ~= "identity" and mode ~= "chunked" then
            error("Unsupport transfer-encoding")
        end
    end

    if mode == "chunked" then
        body, header = M.recvchunkedbody(interface.read, nil, header, body)
        if not body then
            error("Invalid response body")
        end
    else
        -- identity mode
        body = recvbody(interface, code, header, body)
    end

    return body
end

local stream = {}
stream.__index = stream

function stream:close()
    if self._onclose then
        self._onclose(self)
        self._onclose = nil
    end
end

function stream:padding()
    return self._reading(self), self
end

stream.__close = stream.close
stream.__call = stream.padding

local function stream_nobody(stream)
    stream._reading = stream.close
    stream.connected = nil
    return ""
end

local function stream_length(length)
    return function(stream)
        local body = stream._body
        if body == nil then
            local ret = stream._interface.read()
            if not ret then
                -- disconnected
                body = ""
                stream.connected = false
            else
                body = ret
            end
        end
        local n = #body
        if n >= length then
            stream._reading = stream.close
            stream.connected = nil
            return (body:sub(1, length))
        else
            length = length - n
            stream._body = nil
            if not stream.connected then
                stream._reading = stream.close
            end
            return body
        end
    end
end

local function stream_read(stream)
    local ret = stream._interface.read()
    if ret == "" or not ret then
        stream.connected = nil
        stream:close()
        return
    end
    return ret
end

local function stream_all(stream)
    local body = stream._body
    stream._body = nil
    stream._reading = stream_read
    return body
end

local function stream_chunked(stream)
    local read = stream._interface.read
    local sz, body = chunksize(read, stream._body)
    if not sz then
        stream.connected = false
        stream:close()
        return
    end

    if sz == 0 then
        -- last chunk
        local tmpline = {}
        body = M.recvheader(read, tmpline, body)
        if not body then
            stream.connected = false
            stream:close()
            return
        end

        M.parseheader(tmpline, 1, stream.header)

        stream._reading = stream.close
        stream.connected = nil
        return ""
    end

    local n = #body
    local remain

    if n >= sz then
        remain = body:sub(sz + 1)
        body = body:sub(1, sz)
    else
        body = body .. read(sz - n)
        remain = ""
    end
    remain = readcrln(read, remain)
    if not remain then
        stream.connected = false
        stream:close()
        return
    end
    stream._body = remain
    return body
end

function M.response_stream(interface, code, body, header)
    local mode = header["transfer-encoding"]
    if mode then
        if mode ~= "identity" and mode ~= "chunked" then
            error("Unsupport transfer-encoding")
        end
    end

    local read_func

    if mode == "chunked" then
        read_func = stream_chunked
    else
        -- identity mode
        local length = header["content-length"]
        if length then
            length = tonumber(length)
        end
        if length then
            read_func = stream_length(length)
        elseif code == 204 or code == 304 or code < 200 then
            read_func = stream_nobody
        else
            read_func = stream_all
        end
    end

    -- TODO: timeout

    return setmetatable({
        status = code,
        _body = body,
        _interface = interface,
        _reading = read_func,
        header = header,
        connected = true
    }, stream)
end

return M
